/******************************************************************************
 * Shared scripts for pages.
 *****************************************************************************/

(function (root, factory) {
  // Browser globals
  root.utils = factory(root.utils, root.jchanzi || {}, root.Mark);
}(this, function (utils, jchanzi, Mark) {

'use strict';

/**
 * 常數及匯入模組
 */
const {ASCII_WHITESPACE, KEYWORD_COLORS} = utils;

const DEFAULT_OPTIONS = {
  currentTab: "page-panel-tabpanel-option",
  useAncient: "false",
  useZhu: "auto",
  useShu: "auto",
  useJiao: "auto",
  advancedAnnotationFilter: null,
  useDing: "auto",
  advancedVersionFilter: null,
  useVertical: "auto",
  useFont: "auto",
  useFontCustom: "",
  fontSize: 16,
  useHanzi: "auto",
  prettify: "auto",
  tocMode: "section",
};

const SCHEMA_HANDLER = {
  string: {
    fromString: x => x,
    toString: x => x,
  },
  bool: {
    fromString: x => (!!x && x !== '0'),
    toString: x => x && '1' || '0',
  },
  integer: {
    fromString: x => parseInt(x, 10),
    toString: x => x.toString(),
  },
  float: {
    fromString: x => parseFloat(x),
    toString: x => x.toString(),
  },
  json: {
    fromString: x => {
      try {
        return JSON.parse(x);
      } catch (ex) {
        // undefined
      }
    },
    toString: x => JSON.stringify(x),
  },
};

const STORAGE_PREFIX = 'jc_';

const STORAGE_SCHEMA = {
  currentTab: {type: 'string'},
  useAncient: {type: 'string'},
  useZhu: {type: 'string'},
  useShu: {type: 'string'},
  useJiao: {type: 'string'},
  useDing: {type: 'string'},
  useVertical: {type: 'string'},
  useFont: {type: 'string'},
  useFontCustom: {type: 'string'},
  fontSize: {type: 'float'},
  useHanzi: {type: 'string'},
  prettify: {type: 'string'},
  tocMode: {type: 'string'},
};

const URL_PARAMS_SCHEMA = Object.assign({
  locate: {name: 't', type: 'string'},
  highlight: {name: 'hl', type: 'string[]'},
  advancedAnnotationFilter: {type: 'json'},
  advancedVersionFilter: {type: 'json'},
}, STORAGE_SCHEMA);

/** @public */
class Jicheng {
  get DEFAULT_OPTIONS() {
    return DEFAULT_OPTIONS;
  }

  get STORAGE_PREFIX() {
    return STORAGE_PREFIX;
  }

  get SCHEMA_HANDLER() {
    return SCHEMA_HANDLER;
  }

  get STORAGE_SCHEMA() {
    return STORAGE_SCHEMA;
  }

  get URL_PARAMS_SCHEMA() {
    return URL_PARAMS_SCHEMA;
  }

  /**
   * 主要函數 / 介面相關
   */
  constructor() {
    this.options = JSON.parse(JSON.stringify(this.DEFAULT_OPTIONS));
    this.mainElem = null;
    this.mainElemSectioned = null;
    this.renderingElems = new Set();
    this.runtimeOptions = {};
    this.lastTouchElement = null;
    this.titleElems = [];
    this.mapTitleElemToTocElem = new Map();

    document.addEventListener('touchstart', (event) => {
      this.lastTouchElement = event.target;
    });

    document.addEventListener('contextmenu', (event) => {
      if (event.target !== this.lastTouchElement) { return; }

      // 避免影響使用者長按選取複製
      if (event.target.matches('input[type="text"], input[type="number"], textarea')) { return; }

      const elem = event.target.closest('[aria-description], abbr[title]');
      if (!elem) { return; }
      const title = elem.getAttribute('aria-description') || (elem.matches('abbr') && elem.title);
      if (!title) { return; }
      event.preventDefault();
      alert(title);
    });

    document.addEventListener('DOMContentLoaded', (event) => {
      this.init(event);
    });
  }

  init(event) {
    this.mainElem = document.querySelector('main');

    this.mainElemSectioned = this.mainElem.cloneNode(false);
    this.mainElemSectioned.classList.add('sectioned');

    this.registerRenderingElem(this.mainElem, true);
    this.registerRenderingElem(this.mainElemSectioned, true);

    this.initPanel();

    for (const elem of document.querySelectorAll('[aria-description]')) {
      elem.title = elem.getAttribute('aria-description');
    }

    this.loadStorage();
    this.setOptionsToPanel();
    this.loadUrlParams();
    this.togglePanelTab(this.options['currentTab']);
    this.generateToc();
    this.applyOptions({locate: true});
  }

  initPanel() {
    const r = String.raw;
    const PAGE_INFO = utils.PAGE_INFO;

    const useFontOptions = Object.entries(PAGE_INFO.hanzi_fonts || {})
      .map(([name, font]) => r`
          <option value="${name}">${utils.escapeHtml(font.label || name)}</option>`)
      .join('');
    const useFontOptionDescs = Object.entries(PAGE_INFO.hanzi_fonts || {})
      .map(([name, font]) => r`
• ${font.label || name}：${font.desc}`)
      .join('');
    const useFontDesc = r`設定顯示的字體，若系統有安裝會優先使用，否則自動下載網頁需要的字體區段。
• 預設：使用瀏覽器預設字體。${useFontOptionDescs}
• 自訂：使用自訂的字體（點擊旁邊的選項按鈕設定）。
• 自動：程式將按目前瀏覽的頁面及其他選項自動判斷。`;

    const googleSearch = PAGE_INFO.google_cse_script_url ?
      r`
      <script async src="${utils.escapeHtml(PAGE_INFO.google_cse_script_url)}"></script>
      <div class="gcse-search"></div>` :
      PAGE_INFO.google_site_search_root ?
      r`
      <form target="search" action="https://www.google.com/search" style="display: flex; margin: 1em 0; box-sizing: border-box;">
        <input type="text" name="q" style="flex-grow: 1;">
        <input type="hidden" name="q" value="site:{{ assets.get('google_site_search_root') }}">
        <input type="submit" value="Google搜尋">
      </form>` :
      '';

    const bingSearch = PAGE_INFO.bing_site_search_root ?
      r`
      <form id="page-panel-tabpanel-search-bing" target="search" action="https://www.bing.com/search" style="display: flex; margin: 1em 0; box-sizing: border-box;" onsubmit="this.q.value = this.querySelector('[data-name=q]').value + ' ' + this.q.dataset.site;">
        <input type="text" data-name="q" style="flex-grow: 1;">
        <input type="hidden" name="q" data-site="site:${utils.escapeHtml(PAGE_INFO.bing_site_search_root)}">
        <input type="submit" value="Bing搜尋">
      </form>` :
      '';

    const html = `
<div><!-- workaround for https://bugs.chromium.org/p/chromium/issues/detail?id=993339 -->
  <a id="page-panel-close" role="button" aria-label="關閉">╳</a>
  <ul role="tablist">
    <li><button id="page-panel-tab-option" type="button" role="tab" aria-controls="page-panel-tabpanel-option">設定</button></li>
    <li><button id="page-panel-tab-toc" type="button" role="tab" aria-controls="page-panel-tabpanel-toc">目錄</button></li>
    <li><button id="page-panel-tab-search" type="button" role="tab" aria-controls="page-panel-tabpanel-search">搜尋</button></li>
  </ul>
  <div id="page-panel-tabpanels">
    <div id="page-panel-tabpanel-option" role="tabpanel" aria-labelledby="page-panel-tab-option">
      <details open id="page-panel-tabpanel-option-display">
        <summary>呈現樣式</summary>
        <label for="useVertical" aria-description="設定內容是否以直書顯示。
• 自動：程式將按目前瀏覽的頁面及其他選項自動判斷。">直書: <select id="useVertical" size="1">
          <option value="auto" selected>自動</option>
          <option value="false">橫書</option>
          <option value="true">直書</option>
        </select></label>
        <label for="useFont" aria-description="${utils.escapeHtml(useFontDesc)}">字體: <select id="useFont" size="1">
          <option value="auto" selected>自動</option>
          <option value="default">預設</option>${useFontOptions}
          <option value="custom">自訂</option>
        </select></label><button id="useFontCustomBtn" type="button" class="plain" aria-label="自訂字體" aria-description="設定自訂的顯示字體。">⚙️</button>
        <label for="fontSize" aria-description="設定顯示的字體大小，單位為像素(px)。對 Windows 預設的 96DPI 螢幕而言 1px = 0.75pt。">字級: 
          <input id="fontSize" type="number" value="16" min="4" step="any" style="width: 4em;" list="fontSizeOptions">
          <datalist id="fontSizeOptions">
            <option value="16">小</option>
            <option value="20">中</option>
            <option value="24">大</option>
            <option value="32">巨</option>
          </datalist>
        </label>
        <label for="useHanzi" aria-description="設定是否自動處理系統無法顯示的字。
• 基本：自動將有建檔的缺字轉換成圖片。
• 進階：自動將有建檔的缺字轉換成圖片，未建檔的自動合成圖片。
• 停用：不使用自動轉換。瀏覽器一般會把無法顯示的字顯示為空白或 Unicode 編碼。
• 自動：程式將按目前瀏覽的頁面及其他選項自動判斷。">缺字處理: <select id="useHanzi" size="1">
          <option value="auto" selected>自動</option>
          <option value="advanced">進階</option>
          <option value="basic">基本</option>
          <option value="false">停用</option>
        </select></label>
        <label for="prettify" aria-description="設定是否用程式做精細排版。啟用精細排版可能會耗用較多資源及造成延遲，尤其在顯示內容較多時。
• 自動：程式將按目前瀏覽的頁面及其他選項自動判斷。">精細排版: <select id="prettify" size="1">
          <option value="auto" selected>自動</option>
          <option value="true">啟用</option>
          <option value="false">停用</option>
        </select></label>
      </details>
      <details open id="page-panel-tabpanel-option-other">
        <summary>其他</summary>
        <label for="tocMode" aria-description="設定在目錄分頁點擊章節的處理方式。
• 分節：檢視指定的章節，隱藏其他章節，並自動在頁尾加上前後章節的連結。
• 定位：自動捲動定位至指定章節的位置。">目錄瀏覽: <select id="tocMode" size="1">
          <option value="section" selected>分節</option>
          <option value="locate">定位</option>
        </select></label>
      </details>
    </div>
    <div id="page-panel-tabpanel-toc" role="tabpanel" aria-labelledby="page-panel-tab-toc"></div>
    <div id="page-panel-tabpanel-search" role="tabpanel" aria-labelledby="page-panel-tab-search">${googleSearch}${bingSearch}
    </div>
  </div>
</div>
`;

    this.panelElem = document.getElementById('page-panel');

    // allow <script>s be executed
    this.panelElem.appendChild(document.createRange().createContextualFragment(html));

    initOpenWrapper: {
      const elem = document.getElementById('page-panel-open-wrapper');
      if (!elem) { break initOpenWrapper; }

      elem.hidden = false;
    }

    initOpener: {
      const elem = document.getElementById('page-panel-open');
      if (!elem) { break initOpener; }

      elem.href = "javascript:";
      elem.addEventListener('click', (event) => {
        this.togglePanel(true);
      });
    }

    {
      const elem = document.getElementById('page-panel-close');

      elem.href = "javascript:";
      elem.addEventListener('click', (event) => {
        // special handling when closing options tabpanel
        if (document.getElementById('page-panel-tabpanel-option').getAttribute('aria-expanded') === 'true') {
          this.submitOptions();
        }
        this.togglePanel(false);
      });
    }

    for (const elem of this.panelElem.querySelectorAll('[role="tab"]')) {
      elem.addEventListener('click', (event) => {
        this.togglePanelTab(event.currentTarget.getAttribute('aria-controls'));
      });
    }

    {
      const elem = document.getElementById('useFontCustomBtn');

      elem.addEventListener('click', (event) => {
        const title = `請設定要使用的字體名稱：
• 若字體名稱含有空白字元，應使用半形雙引號括住。例如：「"Times New Roman"」。
• 指定多個字體時，用半形逗號分隔，中間可加空白字元。例如：「標楷體, 新細明體」。`;
        let value = prompt(title, this.options.useFontCustom);
        if (value === null) {
          return;
        }
        this.options.useFontCustom = value;
      });
    }
  }

  loadBookMeta(mainElem) {
    const dlElem = mainElem.querySelector('header > dl[class~="元資料"]');
    if (!dlElem) { return {}; }
    return utils.loadDlKeyValue(dlElem);
  }

  togglePanel(willShow) {
    const panel = this.panelElem;
    if (typeof willShow !== "boolean") {
      willShow = !!panel.hidden;
    }
    if (willShow) {
      panel.hidden = false;
      document.querySelector('#page-panel-close').focus();
    } else {
      panel.hidden = true;
    }
  }

  togglePanelTab(toShow) {
    const toShowElem = document.getElementById(toShow);
    if (!toShowElem) {
      toShow = '';
    }
    if (!toShowElem || toShowElem.getAttribute('aria-expanded') !== 'true') {
      // special handling when closing options tabpanel
      if (document.getElementById('page-panel-tabpanel-option').getAttribute('aria-expanded') === 'true') {
        this.submitOptions();
      }

      for (const elem of document.querySelectorAll(`#page-panel [role="tab"]`)) {
        if (elem.getAttribute('aria-controls') === toShow) {
          elem.setAttribute('aria-selected', 'true');
        } else {
          elem.setAttribute('aria-selected', 'false');
        }
      }
      for (const elem of document.querySelectorAll(`#page-panel [role="tabpanel"]`)) {
        if (elem.id === toShow) {
          elem.setAttribute('aria-expanded', 'true');
          elem.hidden = false;
        } else {
          elem.setAttribute('aria-expanded', 'false');
          elem.hidden = true;
        }
      }
    }
    this.options['currentTab'] = toShow;
    this.saveStorage(this.options, {keys: ['currentTab']});
  }

  registerRenderingElem(elem, noRender = false) {
    // 選項改變時觸發
    const onOption = (event) => {
      const wrapper = event.target;
      const options = event.detail.options;

      if (!wrapper.hidden) {
        wrapper.dispatchEvent(new CustomEvent('renderer-beforeshow', {detail: {options: this.runtimeOptions}}));
        wrapper.dispatchEvent(new CustomEvent('renderer-aftershow', {detail: {options: this.runtimeOptions}}));
      }
    };

    // 即將顯示時觸發，此時 wrapper 可能為隱藏狀態（也可能不是）。
    // 主要做不須 computedStyle 等資訊的 DOM 改寫，以減少觸發 reflow/repaint。
    const onBeforeShow = (event) => {
      const wrapper = event.target;
      const options = event.detail.options;

      // record original classes
      const classes0 = new Set(wrapper.classList);
      const classes = new Set(classes0);

      if (wrapper.getAttribute('data-render') === 'book') {
        // 古版
        if (options.useAncient) {
          classes.add('古版');
          if (!classes.has('_古版')) {
            classes.add('_古版');
            this.toggleHiddenContent(wrapper, '[data-rev="古版"][data-jc-innerhtml]');
            this.toggleUnwrappedElems(wrapper, 'jc-t[attr-data-rev="古版-元素"]', '[data-rev="今版-元素"]');
          }
        } else {
          classes.delete('古版');
          if (classes.has('_古版')) {
            classes.delete('_古版');
            this.toggleUnwrappedElems(wrapper, 'jc-t[attr-data-rev="今版-元素"]', '[data-rev="古版-元素"]');
          }
        }

        // 注
        switch (options.useZhu) {
          case "accept":
            classes.add('注-接受');
            classes.delete('注-拒絕');
            break;
          case "reject":
            classes.delete('注-接受');
            classes.add('注-拒絕');
            break;
          case "diff":
          default:
            classes.delete('注-接受');
            classes.delete('注-拒絕');
            break;
        }

        // 疏
        switch (options.useShu) {
          case "accept":
            classes.add('疏-接受');
            classes.delete('疏-拒絕');
            break;
          case "reject":
            classes.delete('疏-接受');
            classes.add('疏-拒絕');
            break;
          case "diff":
          default:
            classes.delete('疏-接受');
            classes.delete('疏-拒絕');
            break;
        }

        // 校
        switch (options.useJiao) {
          case "accept":
            classes.add('校-接受');
            classes.delete('校-拒絕');
            break;
          case "reject":
            classes.delete('校-接受');
            classes.add('校-拒絕');
            break;
          case "diff":
          default:
            classes.delete('校-接受');
            classes.delete('校-拒絕');
            break;
        }

        // 訂
        switch (options.useDing) {
          case "accept":
            classes.add('訂-接受');
            classes.delete('訂-拒絕');
            break;
          case "reject":
            classes.delete('訂-接受');
            classes.add('訂-拒絕');
            break;
          case "diff":
          default:
            classes.delete('訂-接受');
            classes.delete('訂-拒絕');
            break;
        }

        // 進階注疏校篩選
        if (options.advancedAnnotationFilter) {
          for (const key of ['注', '疏', '校']) {
            classes.delete(`${key}-接受`);
            classes.delete(`${key}-拒絕`);
            for (const ver in options.advancedAnnotationFilter[key]) {
              let data = options.advancedAnnotationFilter[key][ver];
              if (data === null || typeof data !== 'object') {
                data = {value: data};
              }
              switch (data.value) {
                case 0:
                  classes.delete(`${key}-${ver}-接受`);
                  classes.add(`${key}-${ver}-拒絕`);
                  break;
                case 1:
                  classes.add(`${key}-${ver}-接受`);
                  classes.delete(`${key}-${ver}-拒絕`);
                  break;
                default:
                  classes.delete(`${key}-${ver}-接受`);
                  classes.delete(`${key}-${ver}-拒絕`);
                  break;
              }
            }
          }
        }

        // 進階訂文篩選
        if (options.advancedVersionFilter) {
          classes.delete(`訂-接受`);
          classes.delete(`訂-拒絕`);
          for (const ver in options.advancedVersionFilter) {
            let data = options.advancedVersionFilter[ver];
            if (data === null || typeof data !== 'object') {
              data = {value: data};
            }
            switch (data.value) {
              case 0:
                classes.delete(`訂-${ver}-接受`);
                classes.add(`訂-${ver}-拒絕`);
                classes.delete(`訂-${ver}-刪除`);
                break;
              case 1:
                classes.add(`訂-${ver}-接受`);
                classes.delete(`訂-${ver}-拒絕`);
                classes.delete(`訂-${ver}-刪除`);
                break;
              case 2:
                classes.delete(`訂-${ver}-接受`);
                classes.delete(`訂-${ver}-拒絕`);
                classes.add(`訂-${ver}-刪除`);
                break;
              default:
                classes.delete(`訂-${ver}-接受`);
                classes.delete(`訂-${ver}-拒絕`);
                classes.delete(`訂-${ver}-刪除`);
                break;
            }
          }
          this.toggleHiddenContent(wrapper, 'span[data-rev="訂"][data-jc-innerhtml]');
        }
      }

      // 標示檢索的關鍵詞
      // 須在 setHanzi 之前，否則無法標示轉成圖片的文字
      if (options.highlight) {
        if (!classes.has('_highlighted')) {
          classes.add('_highlighted');
          this.highlightText(options.highlight, options, wrapper);
        }
      }

      // 精細排版
      if (options.prettify) {
        classes.add('精細排版');
        classes.add('_精細排版');

        // 精細排版可能受其他選項影響，任何選項變化時都要重跑。
        // 先還原以避免二次執行造成錯誤。
        // 因最後仍會做精細排版，還原時不須標記 refreshHanzi。
        onBeforeShowUnprettify(wrapper, true);
        onBeforeShowPrettify(wrapper);
      } else {
        classes.delete('精細排版');
        if (classes.has('_精細排版')) {
          classes.delete('_精細排版');
          onBeforeShowUnprettify(wrapper);
        }
      }

      // apply classes if changed
      if (!utils.setIsEqual(classes, classes0)) {
        wrapper.className = [...classes].join(' ');
      }
    };

    // 顯示時觸發。需要 computedStyle 等資訊的 DOM 改寫只能在此時做。
    const onAfterShow = (event) => {
      const wrapper = event.target;
      const options = event.detail.options;

      // record original classes
      const classes0 = new Set(wrapper.classList);
      const classes = new Set(classes0);

      // hanzi
      setHanzi: {
        if (!jchanzi.processPage) {
          break setHanzi;
        }

        if (options.useHanzi !== wrapper.getAttribute('data-render-useHanzi')) {
          wrapper.setAttribute('data-render-useHanzi', options.useHanzi);
          jchanzi.unprocessElement(wrapper);
          switch (options.useHanzi) {
            case 'basic':
              jchanzi.conf.convertIdsDynamic = 0;
              jchanzi.processElement(wrapper);
              break;
            case 'advanced':
              jchanzi.conf.convertIdsDynamic = 1;
              jchanzi.processElement(wrapper);
              break;
          }
        } else {
          // 某些情況須重新處理部分子節點
          switch (options.useHanzi) {
            case 'basic':
              jchanzi.conf.convertIdsDynamic = 0;
              for (const elem of wrapper.querySelectorAll('[data-render-prettify-refreshHanzi]')) {
                jchanzi.processElement(elem);
              }
              break;
            case 'advanced':
              jchanzi.conf.convertIdsDynamic = 1;
              for (const elem of wrapper.querySelectorAll('[data-render-prettify-refreshHanzi]')) {
                jchanzi.processElement(elem);
              }
              break;
          }
        }

        for (const elem of wrapper.querySelectorAll('[data-render-prettify-refreshHanzi]')) {
          elem.removeAttribute('[data-render-prettify-refreshHanzi]');
        }
      }

      // apply classes if changed
      if (!utils.setIsEqual(classes, classes0)) {
        wrapper.className = [...classes].join(' ');
      }
    };

    const onBeforeShowPrettify = (wrapper) => {
      // 靠右小字, 靠左小字, 略小字
      // 讓每個字確實置中對齊
      {
        for (const elem of wrapper.querySelectorAll('small.組排小字, small.雙行夾注 span.行, small.靠右小字, small.靠左小字, small.略小字')) {
          const nodeIterator = document.createNodeIterator(elem,
            NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
            (node) => node.parentNode.matches('jc-c') ? NodeFilter.FILTER_SKIP : NodeFilter.FILTER_ACCEPT
            );

          let node;
          while (node = nodeIterator.nextNode()) {
            if (node.nodeType === Node.TEXT_NODE) {
              const chars = Array.from(node.data);
              const frag = document.createDocumentFragment();
              for (const char of chars) {
                const elem = frag.appendChild(document.createElement('jc-c'));
                elem.textContent = char;
              }
              // don't use replaceChild, which causes frag be iterated at the next run
              node.before(frag);
              node.remove();
              continue;
            }

            if (node.matches('a[data-jchanzi]')) {
              const wrapper = document.createElement('jc-c');
              node.before(wrapper);
              wrapper.appendChild(node);
              continue;
            }
          }
        }
      }
    };

    const onBeforeShowUnprettify = (wrapper, skipRefresh = false) => {
      // 特定情況可能須重跑 hanzi，故先標記須重新處理缺字的節點
      // 例如以下情況可能導致 IDS 因之前被分開而無法合成：
      // useHanzi: false => prettify: true => useHanzi: advanced => prettify: false
      const refreshHanzi = !skipRefresh && ['basic', 'advanced'].includes(wrapper.getAttribute('data-render-useHanzi'));

      // 靠右小字, 靠左小字, 略小字
      {
        for (const elem of wrapper.querySelectorAll('small.組排小字, small.雙行夾注 span.行, small.靠右小字, small.靠左小字, small.略小字')) {
          for (const node of elem.querySelectorAll('jc-c')) {
            const frag = document.createDocumentFragment();
            let child;
            while (child = node.firstChild) {
              frag.appendChild(child);
            }
            node.replaceWith(frag);
          }
          elem.normalize();

          if (refreshHanzi) {
            elem.setAttribute('data-render-prettify-refreshHanzi', '');
          }
        }
      }
    };

    if (elem.getAttribute('data-render') === 'norender') {
      return;
    }

    if (this.renderingElems.has(elem)) {
      return;
    }

    this.renderingElems.add(elem);
    elem.addEventListener('renderer-option', onOption);
    elem.addEventListener('renderer-beforeshow', onBeforeShow);
    elem.addEventListener('renderer-aftershow', onAfterShow);

    if (!noRender) {
      elem.dispatchEvent(new CustomEvent('renderer-option', {detail: {options: this.runtimeOptions}}));
    }
  }

  showRenderingElem(elem, options = this.runtimeOptions) {
    if (options !== this.runtimeOptions) {
      options = Object.assign({}, this.runtimeOptions, options);
    }
    elem.dispatchEvent(new CustomEvent('renderer-beforeshow', {detail: {options}}));
    elem.hidden = false;
    elem.dispatchEvent(new CustomEvent('renderer-aftershow', {detail: {options}}));
  }

  /**
   * Attempt to submit user options.
   *
   * @throws {Error} if the form doesn't pass validation
   */
  submitOptions() {
    if (!document.querySelector('#page-panel form').reportValidity()) {
      throw new Error('invalid options');
    }
    this.loadOptionsFromPanel();
    this.applyOptions();
    this.saveStorage();
  }

  applyOptions(...args) {
    const cssElem = document.head.appendChild(document.createElement('style'));
    cssElem.classList.add('options');

    const applyOptions = this.applyOptions = ({
      locate = false,
    } = {}) => {
      // update runtime options
      const options0 = Object.assign({}, this.runtimeOptions);
      this.updateRuntimeOptions();
      const options = this.runtimeOptions;

      // skip if options not changed
      if (Object.keys(options).every(k => options[k] === options0[k])) {
        return;
      }

      // get locate target
      let locateTarget;
      let locateTargetToc;
      getLocateTarget: {
        if (!locate) { break getLocateTarget; }

        let target = options['locate'];
        if (!target) { break getLocateTarget; }

        let targetElem;
        try {
          targetElem = document.evaluate(target, this.mainElem, null, XPathResult.ANY_TYPE, null).iterateNext();
          if (!targetElem) { throw new Error(`Matched node is not found`); }
        } catch (ex) {
          console.error(`Unable to locate '${target}': ${ex.message}`);
          break getLocateTarget;
        }

        locateTarget = targetElem.parentNode.insertBefore(document.createElement('jc-locate'), targetElem);
        locateTargetToc = this.getParentSection(targetElem);
      }

      // apply document-level changes
      const htmlClasses0 = new Set(document.documentElement.classList);
      const htmlClasses = new Set(htmlClasses0);
      const styleSheets = [];

      this.setVerticalMode(options.useVertical, htmlClasses);
      this.setAdvancedAnnotationFilter(options.advancedAnnotationFilter, styleSheets);
      this.setAdvancedVersionFilter(options.advancedVersionFilter, styleSheets);
      this.setFontMode(options.fontFamily, styleSheets);
      this.setFontSize(options.fontSize, styleSheets);

      if (!utils.setIsEqual(htmlClasses, htmlClasses0)) {
        document.documentElement.className = [...htmlClasses].join(' ');
      }

      const cssText = styleSheets.join('\n');
      if (cssText !== cssElem.textContent) {
        cssElem.textContent = cssText;
      }

      // 分派事件
      for (const elem of this.renderingElems) {
        elem.dispatchEvent(new CustomEvent('renderer-option', {detail: {options}}));
      }

      // 處理 main 以外的其他元素
      setHanzi: {
        if (!jchanzi.processPage) {
          break setHanzi;
        }

        const wrappers = [
          document.getElementById('page-header'),
          document.getElementById('page-panel'),
          document.getElementById('page-footer'),
        ];
        if (options.useHanzi !== document.body.getAttribute('data-render-useHanzi')) {
          document.body.setAttribute('data-render-useHanzi', options.useHanzi);
          for (const wrapper of wrappers) {
            jchanzi.unprocessElement(wrapper);
          }
          switch (options.useHanzi) {
            case 'basic':
              jchanzi.conf.convertIdsDynamic = 0;
              for (const wrapper of wrappers) {
                jchanzi.processElement(wrapper);
              }
              break;
            case 'advanced':
              jchanzi.conf.convertIdsDynamic = 1;
              for (const wrapper of wrappers) {
                jchanzi.processElement(wrapper);
              }
              break;
          }
        }
      }

      // 跳轉至目標元素
      // 須在各樣式設定完之後，以便計算正確的捲動位置
      if (locateTarget) {
        const mapOrigToClone = new WeakMap();
        this.loadSection(locateTargetToc, {
          locate: false,
          closePanel: false,
          mapOrigToClone,
        });

        if (!this.mainElem.isConnected) {
          const locateTargetClone = mapOrigToClone.get(locateTarget);
          locateTarget.remove();
          locateTarget = locateTargetClone;
        }

        // locateTarget may not be cloned if it's just before locateTargetToc
        if (locateTarget) {
          this.locateElement(locateTarget);
          locateTarget.remove();
        }
      }
    };

    return applyOptions(...args);
  }

  /**
   * 轉換用 data-jc-innerhtml 隱藏的元素內容
   *
   * - 主要用於避免這些內容被搜尋引擎建索引
   * - 通常在載入文件後只須執行一次
   */
  toggleHiddenContent(wrapper, toShowSelector, toHideSelector, onReplace) {
    if (toShowSelector) {
      for (const elem of wrapper.querySelectorAll(toShowSelector)) {
        elem.innerHTML = elem.getAttribute('data-jc-innerhtml');
        elem.removeAttribute('data-jc-innerhtml');
        onReplace && onReplace(elem);
      }
    }
    if (toHideSelector) {
      for (const elem of wrapper.querySelectorAll(toHideSelector)) {
        elem.setAttribute('data-jc-innerhtml', elem.innerHTML);
        elem.innerHTML = '';
        onReplace && onReplace(elem);
      }
    }
  }

  /**
   * 轉換用 jc-t 隱藏的元素
   *
   * - 使這些元素透明化，避免影響樣式呈現
   * - 與選項有關，通常在需要渲染或計算 XPath/CSS selector 時才執行
   */
  toggleUnwrappedElems(wrapper, toShowSelector, toHideSelector, onReplace) {
    const doc = wrapper.ownerDocument;
    if (toShowSelector) {
      for (const elem of wrapper.querySelectorAll(toShowSelector)) {
        const rElem = doc.createElement(elem.getAttribute('tag'));
        for (const attr of elem.attributes) {
          const nodeName = attr.nodeName;
          if (!nodeName.startsWith('attr-')) {
            continue;
          }
          rElem.setAttribute(nodeName.slice(5), attr.nodeValue);
        }
        let child;
        while (child = elem.firstChild) {
          rElem.appendChild(child);
        }
        elem.replaceWith(rElem);
        onReplace && onReplace(elem, rElem);
      }
    }
    if (toHideSelector) {
      for (const elem of wrapper.querySelectorAll(toHideSelector)) {
        const rElem = doc.createElement('jc-t');
        rElem.setAttribute('tag', elem.nodeName.toLowerCase());
        for (const attr of elem.attributes) {
          rElem.setAttribute(`attr-${attr.nodeName}`, attr.nodeValue);
        }
        let child;
        while (child = elem.firstChild) {
          rElem.appendChild(child);
        }
        elem.replaceWith(rElem);
        onReplace && onReplace(elem, rElem);
      }
    }
  }

  /**
   * 切換直書模式
   */
  setVerticalMode(useVertical, classes) {
    const handlerModifiers = ['ctrlKey', 'altKey', 'shiftKey', 'metaKey'];

    const keyboardHandler = (event) => {
      if (handlerModifiers.some(x => !!event[x])) { return; }
      if (!this.panelElem.hidden) { return; }

      switch (event.key) {
        case 'PageUp':
          window.scrollBy(getPageHeight('X'), 0);
          event.preventDefault();
          break;
        case "PageDown":
          window.scrollBy(-getPageHeight('X'), 0);
          event.preventDefault();
          break;
        case 'End':
          window.scrollTo(-document.documentElement.scrollWidth, 0);
          event.preventDefault();
          break;
        case 'Home':
          window.scrollTo(0, 0);
          event.preventDefault();
          break;
      }
    };

    const getLineHeight = (elem) => {
      return parseInt(window.getComputedStyle(elem, null).lineHeight, 10) || 16;
    };

    const getPageHeight = (axis) => {
      switch (axis) {
        case 'X': {
          return document.documentElement.clientWidth;
        }
        case 'Y':
        default: {
          return document.documentElement.clientHeight;
        }
      }
    };

    // @TODO: allow scrolling a scrollbar in the content
    const mouseWheelHandler = (event) => {
      if (handlerModifiers.some(x => !!event[x])) { return; }
      if (event.deltaY === 0) { return; }
      if (!this.panelElem.hidden) { return; }

      let delta = event.deltaY;
      switch (event.deltaMode) {
        case WheelEvent.DOM_DELTA_LINE: {
          delta *= getLineHeight(event.target);
          break;
        }
        case WheelEvent.DOM_DELTA_PAGE: {
          delta *= getPageHeight('X');
          break;
        }
      }

      window.scrollBy(-delta, 0);
      event.preventDefault();
    };

    let isVertical;

    const setVerticalMode = this.setVerticalMode = (useVertical, classes) => {
      if (useVertical) {
        classes.add('直書');
        if (!isVertical) {
          // some browsers defaults events like wheel to passive: true
          window.addEventListener('keydown', keyboardHandler, {passive: false});
          window.addEventListener('wheel', mouseWheelHandler, {passive: false});
        }
      } else {
        classes.delete('直書');
        if (isVertical) {
          window.removeEventListener('keydown', keyboardHandler);
          window.removeEventListener('wheel', mouseWheelHandler);
        }
      }

      isVertical = useVertical;
    };

    return setVerticalMode(useVertical, classes);
  }

  /**
   * 進階注疏校篩選
   */
  setAdvancedAnnotationFilter(...args) {
    const getCssText = (data) => {
      if (data === null || typeof data !== 'object') {
        data = {value: data};
      }
      try {
        return Object.entries(data.css).reduce((rv, [k, v]) => {
          v = utils.tidyCssPropertyValue(k, v);
          if (v) {
            rv.push(`${k}: ${v};`);
          }
          return rv;
        }, []).join(' ');
      } catch (ex) {
        return '';
      }
    };

    const setAdvancedAnnotationFilter = this.setAdvancedAnnotationFilter = (advancedAnnotationFilter, styleSheets) => {

      if (!advancedAnnotationFilter) { return; }
      const styles = [[], [], []];
      for (const key of ['注', '疏', '校']) {
        for (let ver in advancedAnnotationFilter[key]) {
          let cssText = getCssText(advancedAnnotationFilter[key][ver]);
          ver = CSS.escape(ver);
          if (!ver) {
            styles[0].push(`[data-render="book"].${key}-${ver}-接受 span[data-rev="${key}"]:not([data-ver])`);
            styles[1].push(`[data-render="book"].${key}-${ver}-拒絕 span[data-rev="${key}"]:not([data-ver])`);
            if (cssText) {
              styles[2].push(`\
[data-render="book"]:not(.${key}-${ver}-接受):not(.${key}-${ver}-拒絕) span[data-rev="${key}"]:not([data-ver]),
[data-render="book"].${key}-${ver}-接受 span[data-rev="${key}"]:not([data-ver]),
[data-render="book"].${key}-${ver}-拒絕 span[data-rev="${key}"]:not([data-ver]) {
  ${cssText}
}`);
            }
          }
          styles[0].push(`[data-render="book"].${key}-${ver}-接受 span[data-rev="${key}"][data-ver="${ver}"]`);
          styles[1].push(`[data-render="book"].${key}-${ver}-拒絕 span[data-rev="${key}"][data-ver="${ver}"]`);
          if (cssText) {
            styles[2].push(`\
[data-render="book"]:not(.${key}-${ver}-接受):not(.${key}-${ver}-拒絕) span[data-rev="${key}"][data-ver="${ver}"],
[data-render="book"].${key}-${ver}-接受 span[data-rev="${key}"][data-ver="${ver}"],
[data-render="book"].${key}-${ver}-拒絕 span[data-rev="${key}"][data-ver="${ver}"] {
  ${cssText}
}`);
          }
        }
      }
      styleSheets.push(`\
${styles[0].join(', ')} {
  color: unset;
}
${styles[1].join(', ')} {
  display: none;
}
${styles[2].join('\n')}`);
    };

    return setAdvancedAnnotationFilter(...args);
  }

  /**
   * 進階訂文篩選
   */
  setAdvancedVersionFilter(...args) {
    const getCssText = (data) => {
      if (data === null || typeof data !== 'object') {
        data = {value: data};
      }
      try {
        return Object.entries(data.css).reduce((rv, [k, v]) => {
          v = utils.tidyCssPropertyValue(k, v);
          if (v) {
            rv.push(`${k}: ${v};`);
          }
          return rv;
        }, []).join(' ');
      } catch (ex) {
        return '';
      }
    };

    const setAdvancedVersionFilter = this.setAdvancedVersionFilter = (advancedVersionFilter, styleSheets) => {
      if (!advancedVersionFilter) { return; }
      const styles = [[], [], [], []];
      for (let ver in advancedVersionFilter) {
        let cssText = getCssText(advancedVersionFilter[ver]);
        ver = CSS.escape(ver);
        if (!ver) {
          styles[0].push(`[data-render="book"].訂-${ver}-接受 span[data-rev="訂"]:not([data-ver])`);
          styles[1].push(`[data-render="book"].訂-${ver}-拒絕 span[data-rev="訂"]:not([data-ver])`);
          styles[2].push(`[data-render="book"].訂-${ver}-刪除 span[data-rev="訂"]:not([data-ver])`);
          if (cssText) {
            styles[3].push(`\
[data-render="book"]:not(.訂-${ver}-接受):not(.訂-${ver}-拒絕):not(.訂-${ver}-刪除) span[data-rev="訂"]:not([data-ver]),
[data-render="book"].訂-${ver}-接受 span[data-rev="訂"]:not([data-ver]),
[data-render="book"].訂-${ver}-拒絕 span[data-rev="訂"]:not([data-ver]),
[data-render="book"].訂-${ver}-刪除 span[data-rev="訂"]:not([data-ver]) {
  ${cssText}
}`);
          }
        }
        styles[0].push(`[data-render="book"].訂-${ver}-接受 span[data-rev="訂"][data-ver="${ver}"]`);
        styles[1].push(`[data-render="book"].訂-${ver}-拒絕 span[data-rev="訂"][data-ver="${ver}"]`);
        styles[2].push(`[data-render="book"].訂-${ver}-刪除 span[data-rev="訂"][data-ver="${ver}"]`);
        if (cssText) {
          styles[3].push(`\
[data-render="book"]:not(.訂-${ver}-接受):not(.訂-${ver}-拒絕):not(.訂-${ver}-刪除) span[data-rev="訂"][data-ver="${ver}"],
[data-render="book"].訂-${ver}-接受 span[data-rev="訂"][data-ver="${ver}"],
[data-render="book"].訂-${ver}-拒絕 span[data-rev="訂"][data-ver="${ver}"],
[data-render="book"].訂-${ver}-刪除 span[data-rev="訂"][data-ver="${ver}"] {
  ${cssText}
}`);
        }
      }
      styleSheets.push(`\
[data-render="book"] span[data-rev="訂"][data-ver]:not([data-ver="*"]) {
  display: unset;
}
[data-render="book"] span[data-rev="訂"][data-ver="*"] {
  text-decoration: unset;
}
${styles[0].join(', ')} {
  color: unset;
}
${styles[1].join(', ')} {
  display: none;
}
${styles[2].join(', ')} {
  text-decoration: line-through;
}
${styles[3].join('\n')}`);
    };

    return setAdvancedVersionFilter(...args);
  }

  /**
   * 切換字體
   */
  setFontMode(fontFamily, styleSheets) {
    if (!fontFamily) { return; }

    let fontCss = '';
    const font = utils.PAGE_INFO.hanzi_fonts[fontFamily];
    if (font) {
      fontFamily = font.family;
      fontCss = font.css;
    }

    styleSheets.unshift(fontCss); // @import must go first
    styleSheets.push(`\
.main, #page-panel-tabpanel-toc {
  font-family: ${fontFamily};
}`);
  }

  setFontSize(fontSize, styleSheets) {
    if (Number.isFinite(fontSize)) {
      styleSheets.push(`html { font-size: ${fontSize}px; }`);
    }
  }

  highlightText(...args) {
    const REGEX_PATTERN = /^\/(.*)\/([A-Za-z]*?)$/;
    const highlightText = this.highlightText = (patterns, options, elem = this.mainElem) => {
      let regexes;
      try {
        regexes = patterns.map(pattern => {
          try {
            if (REGEX_PATTERN.test(pattern)) {
              return new RegExp(RegExp.$1, RegExp.$2);
            }
            return new RegExp(utils.escapeRegExp(pattern));
          } catch (ex) {
            throw new Error(`${pattern}: ${ex.message}`);
          }
        });
      } catch (ex) {
        console.error(`Unable to highlight: ${ex.message}`);
        return;
      }

      // selector 比對 textNode.parentNode
      const excludeSelectors = [];
      if (options.useAncient) {
        excludeSelectors.push('[data-rev="今版"]');
      } else {
        excludeSelectors.push('[data-rev="古版"]');
      }
      switch (options.useZhu) {
        case "reject":
          excludeSelectors.push('span[data-rev="注"]');
          break;
      }
      switch (options.useShu) {
        case "reject":
          excludeSelectors.push('span[data-rev="疏"]');
          break;
      }
      switch (options.useJiao) {
        case "reject":
          excludeSelectors.push('span[data-rev="校"]');
          break;
      }
      switch (options.useDing) {
        case "accept":
          excludeSelectors.push('span[data-rev="訂"][data-ver]');
          break;
        case "reject":
          excludeSelectors.push('span[data-rev="訂"]:not([data-ver="*"])');
          break;
        case "diff":
        default:
          excludeSelectors.push('span[data-rev="訂"][data-ver]:not([data-ver="*"])');
          break;
      }

      this.mark(elem, regexes, {
        acrossElements: true,
        exclude: excludeSelectors,
      });
    };
    return highlightText(...args);
  }

  /**
   * @typedef {(RegExp[]|{pattern: RegExp, index: Number)}[]} MarkMap
   */

  /**
   * Mark an element with color and tabindex using regexes.
   *
   * @requires Mark
   * @param {Element} elem - The element to mark.
   * @param {MarkMap} regexes - The Map of RegExps for marking.
   * @param {Object} options
   * @param {boolean} options.acrossElements - Mark across elements (for Mark.js).
   * @param {exclude} options.exclude - The exclude selector (for Mark.js).
   * @param {exclude} options.withTabIndex - Add tabindex=0 attribute.
   */
  mark(elem, regexes, {acrossElements = false, exclude = [], withTabIndex = true} = {}) {
    regexes.forEach((item, index) => {
      const {pattern: regex, index: kwIndex} = item instanceof RegExp ? {pattern: item, index} : item;
      const options = {
        acrossElements,
        exclude,
      };
      if (Number.isInteger(kwIndex)) {
        options.className = `kw${kwIndex % KEYWORD_COLORS}`;
      }
      if (withTabIndex) {
        options.each = (elem) => {
          elem.setAttribute('tabindex', '0');
        };
      }
      new Mark(elem).markRegExp(regex, options);
    });
  }

  generateToc() {
    const titleElems = this.titleElems = Array.from(this.mainElem.querySelectorAll('[data-sec="h1"], [data-sec="h2"], [data-sec="h3"], [data-sec="h4"], [data-sec="h5"], [data-sec="h6"]'))
        .filter(x => !x.closest('main > header'));

    const root = document.querySelector('#page-panel-tabpanel-toc');
    let prevElem = root;
    let prevLevel = titleElems.reduce((curValue, titleElem) => {
      const level = parseInt(titleElem.getAttribute('data-sec').match(/(\d+)$/)[1], 10);
      return Math.min(curValue, level);
    }, Infinity);

    // 全文
    {
      const ol = document.createElement('ol');
      root.appendChild(ol);
      const li = document.createElement('li');
      ol.appendChild(li);

      const a = document.createElement('a');
      a.href = this.setUrlParams({
        locate: '',
      });
      a.textContent = '（全文）';
      a.addEventListener('click', (event) => {
        this.loadSection(null);
      });
      li.appendChild(a);
      this.mapTitleElemToTocElem.set(null, a);

      prevElem = li;
    }

    // 首節
    {
      const li = document.createElement('li');
      prevElem.parentNode.appendChild(li);

      const a = document.createElement('a');
      a.href = this.setUrlParams({
        locate: '.',
      });
      a.textContent = '（首節）';
      a.addEventListener('click', (event) => {
        this.loadSection(this.mainElem);
      });
      li.appendChild(a);
      this.mapTitleElemToTocElem.set(this.mainElem, a);

      prevElem = li;
    }

    const map = new WeakMap();
    for (const titleElem of titleElems) {
      const level = parseInt(titleElem.getAttribute('data-sec').match(/(\d+)$/)[1], 10);
      const li = document.createElement('li');

      if (level > prevLevel) {
        let curElem = prevElem;
        while (level > prevLevel) {
          curElem = curElem.appendChild(document.createElement('ol'));
          if (level - prevLevel > 1) {
            curElem = curElem.appendChild(document.createElement('li'));
          }
          prevLevel++;
        }
        curElem.appendChild(li);
      } else if (level < prevLevel) {
        let curElem = prevElem;
        while (prevLevel > level) {
          curElem = curElem.parentNode.parentNode;
          prevLevel--;
        }
        curElem.parentNode.appendChild(li);
      } else {
        prevElem.parentNode.appendChild(li);
      }

      const a = document.createElement('a');
      a.textContent = this.getTocText(titleElem);
      a.href = this.setUrlParams({
        locate: utils.getXPath(titleElem, this.mainElem, map),
      });
      a.addEventListener('click', (event) => {
        this.loadSection(titleElem);
      });
      li.appendChild(a);
      this.mapTitleElemToTocElem.set(titleElem, a);

      prevElem = li;
      prevLevel = level;
    }

    // 標示（全文）項
    const selectedTocElem = this.mapTitleElemToTocElem.get(null);
    if (selectedTocElem) {
      selectedTocElem.classList.add('current');
    }
  }

  /**
   * 載入章節
   *
   * @param {*} titleElem - 欲載入的章節元素
   *     - falsy: 顯示全文, mainElem: 顯示首節, 其他: 顯示該標題元素所在節
   */
  loadSection(titleElem, {
    locate = true,
    closePanel = true,
    mapOrigToClone = null,
    mapCloneToOrig = null,
  } = {}) {
    // 關閉工具欄
    if (closePanel) {
      this.togglePanel(false);
    }

    const selectedTocElem = this.mapTitleElemToTocElem.get(titleElem || null);

    // titleElem 不是有效的章節標題
    if (!selectedTocElem) {
      throw new Error('Attempt to load an invalid section.');
    }

    // 標示選擇的目錄項
    for (const elem of document.querySelectorAll('#page-panel-tabpanel-toc a.current')) {
      elem.classList.remove('current');
    }
    selectedTocElem.classList.add('current');

    // 定位模式時，特別處理
    if (this.options['tocMode'] === 'locate') {
      // 顯示全文
      if (!this.mainElem.isConnected) {
        this.mainElem.hidden = true;
        this.mainElemSectioned.replaceWith(this.mainElem);
        this.showRenderingElem(this.mainElem);
      }

      if (titleElem) {
        if (locate) {
          this.locateElement(titleElem);
        }
      } else {
        if (locate) {
          window.scrollTo(0, 0);
        }
      }

      return;
    }

    if (!titleElem) {
      // 顯示全文
      if (!this.mainElem.isConnected) {
        this.mainElem.hidden = true;
        this.mainElemSectioned.replaceWith(this.mainElem);
        this.showRenderingElem(this.mainElem);
      }
    } else {
      let prevTitleElem = null;
      let nextTitleElem = null;

      if (titleElem === this.mainElem) {
        // 首節
        nextTitleElem = this.titleElems[0] || nextTitleElem;
      } else {
        const idx = this.titleElems.indexOf(titleElem);
        prevTitleElem = this.titleElems[idx - 1] || prevTitleElem;
        nextTitleElem = this.titleElems[idx + 1] || nextTitleElem;
      }

      // 設定要顯示的元素
      this.mainElemSectioned.hidden = true;
      if (this.mainElem.isConnected) {
        this.mainElem.replaceWith(this.mainElemSectioned);
      }

      this.mainElemSectioned.classList = this.mainElem.classList;
      this.mainElemSectioned.classList.add('sectioned');
      this.mainElemSectioned.textContent = '';
      this.mainElemSectioned.appendChild(this.cloneFragmentBetween(this.mainElem, titleElem, nextTitleElem, {
        mapOrigToClone, mapCloneToOrig,
      }));

      // 建立「上一節」「下一節」
      {
        const footerElem = this.mainElemSectioned.appendChild(document.createElement('footer'));

        if (prevTitleElem) {
          const div = footerElem.appendChild(document.createElement('div'));
          div.textContent = '上一節：';

          const a = div.appendChild(document.createElement('a'));
          a.innerHTML = this.mapTitleElemToTocElem.get(prevTitleElem).innerHTML;
          a.href = this.setUrlParams({
            locate: utils.getXPath(prevTitleElem, this.mainElem),
          });
          a.addEventListener('click', (event) => {
            this.loadSection(prevTitleElem);
          });
        } else if (titleElem !== this.mainElem) {
          const div = footerElem.appendChild(document.createElement('div'));
          div.textContent = '上一節：';

          const a = div.appendChild(document.createElement('a'));
          a.textContent = '（首節）';
          a.href = this.setUrlParams({
            locate: '.',
          });
          a.addEventListener('click', (event) => {
            this.loadSection(this.mainElem);
          });
        } else {
          const div = footerElem.appendChild(document.createElement('div'));
          div.textContent = '上一節：無';
        }

        if (nextTitleElem) {
          const div = footerElem.appendChild(document.createElement('div'));
          div.textContent = '下一節：';

          const a = div.appendChild(document.createElement('a'));
          a.innerHTML = this.mapTitleElemToTocElem.get(nextTitleElem).innerHTML;
          a.href = this.setUrlParams({
            locate: utils.getXPath(nextTitleElem, this.mainElem),
          });
          a.addEventListener('click', (event) => {
            this.loadSection(nextTitleElem);
          });
        } else {
          const div = footerElem.appendChild(document.createElement('div'));
          div.textContent = '下一節：無';
        }
      }

      // 顯示 mainElemSectioned，因其內容可能變動，一律重新 dispatchEvent
      this.showRenderingElem(this.mainElemSectioned);
    }

    if (locate) {
      window.scrollTo(0, 0);
    }
  }

  /**
   * 取得包含指定元素的章節元素
   */
  getParentSection(elem) {
    let curTitleElem = this.mainElem;
    for (const titleElem of this.titleElems) {
      if (titleElem.compareDocumentPosition(elem) & Node.DOCUMENT_POSITION_PRECEDING) {
        break;
      }
      curTitleElem = titleElem;
    }
    return curTitleElem;
  }

  getTocText(elem) {
    const cloneElem = elem.cloneNode(true);

    for (const elem of cloneElem.querySelectorAll('[data-sec=""], [data-rev="古版"]')) {
      elem.remove();
    }

    setHanzi: {
      if (!jchanzi.processPage) {
        break setHanzi;
      }

      jchanzi.unprocessElement(cloneElem);
    }

    return cloneElem.textContent;
  }

  cloneFragmentBetween(mainElem, startElem, endElem, {
    mapOrigToClone = null,
    mapCloneToOrig = null,
  } = {}) {
    const range = new Range();
    if (startElem !== mainElem) {
      range.setStartBefore(startElem);
    } else {
      range.setStart(mainElem, 0);
    }
    if (endElem) {
      range.setEndBefore(endElem);
    } else {
      range.setEnd(mainElem, mainElem.childNodes.length);
    }
    const ca = range.commonAncestorContainer;

    const wrappers = [];
    let tempNode = ca;
    while (tempNode && tempNode !== mainElem) {
      wrappers.unshift(tempNode);
      tempNode = tempNode.parentNode;
    }

    const frag = document.createDocumentFragment();
    tempNode = frag;
    for (const wrapper of wrappers) {
      const tempNodeClone = wrapper.cloneNode(false);
      mapOrigToClone && mapOrigToClone.set(wrapper, tempNodeClone);
      mapCloneToOrig && mapCloneToOrig.set(tempNodeClone, wrapper);
      tempNode = tempNode.appendChild(tempNodeClone);
    }

    tempNode.appendChild(range.cloneContents());
    if (mapOrigToClone || mapCloneToOrig) {
      const walker1 = ca.ownerDocument.createNodeIterator(ca);
      const walker2 = tempNode.ownerDocument.createNodeIterator(tempNode);
      let node1 = walker1.nextNode();
      let node2 = walker2.nextNode();
      if (startElem !== mainElem) {
        while (node1) {
          if (node1 === startElem) { break; }
          node1 = walker1.nextNode();
        }
      } else {
        node1 = walker1.nextNode();
      }
      node2 = walker2.nextNode();
      while (node1) {
        mapOrigToClone && mapOrigToClone.set(node1, node2);
        mapCloneToOrig && mapCloneToOrig.set(node2, node1);
        node1 = walker1.nextNode();
        node2 = walker2.nextNode();
        if (node1 === endElem) { break; }
      }
    }

    return frag;
  }

  // @TODO: 若 target 的 parent 為 pre，Chrome 取得的 domRect 數值會不正確
  locateElement(target) {
    if (!target) { return; }

    // target 被隱藏時無法定位，建立暫時節點作為參照
    const getUppermostHiddenParent = (node) => {
      let curNode = node;
      while (!node.offsetParent) {
        curNode = node;
        node = node.parentNode;
      }
      return curNode;
    };

    let ref = target;
    if (!ref.offsetParent) {
      ref = getUppermostHiddenParent(ref);
      ref = ref.parentNode.insertBefore(document.createElement('jc-locate'), ref);
    }

    if (!document.documentElement.classList.contains('直書')) {
      const domRect = ref.getBoundingClientRect();
      window.scrollBy(domRect.left, domRect.top);
    } else {
      const domRect = ref.getBoundingClientRect();
      const viewportWidth = document.documentElement.clientWidth;
      const deltaX = domRect.left + domRect.width - viewportWidth;
      window.scrollBy(deltaX, domRect.top);
    }

    if (ref !== target) {
      ref.remove();
    }
  }

  loadOptionsFromPanel(object = this.options, {
    keys = Object.keys(this.options),
    selectorMap = key => `#${CSS.escape(key)}`,
  } = {}) {
    for (const key of keys) {
      const selector = selectorMap(key);
      const elem = document.querySelector(selector);
      if (elem === null) {
        continue;
      }

      if (elem.matches('input[type="checkbox"]')) {
        object[key] = elem.checked;
      } else if (elem.matches('select[multiple]')) {
        const data = Array.prototype.reduce.call(elem.querySelectorAll('option'), (data, el, i) => {
          data.values.push(el.value);
          if (el.selected) {
            const last = data.selected[data.selected.length - 1];
            if (last && last.end === i - 1) {
              last.end = i;
            } else {
              data.selected.push({start: i, end: i});
            }
          }
          return data;
        }, {values: [], selected: []});
        const selected = data.selected.reduce((rv, x) => {
          rv.push(utils.toBaseN(x.start, 62) + (x.end > x.start ? '-' + utils.toBaseN(x.end, 62) : ''));
          return rv;
        }, []);
        const crc = utils.crc32(JSON.stringify(data.values), '62');
        object[key] = selected.join(' ') + '  ' + crc;
      } else if (elem.matches('input[type="number"]')) {
        object[key] = elem.validity.valid && elem.value !== "" ? elem.valueAsNumber : null;
      } else {
        object[key] = elem.value;
      }
    }
    return object;
  }

  setOptionsToPanel(object = this.options, {
    keys = Object.keys(this.options),
    selectorMap = key => `#${CSS.escape(key)}`,
  } = {}) {
    for (const key of keys) {
      if (typeof object[key] === 'undefined') {
        continue;
      }

      const selector = selectorMap(key);
      const elem = document.querySelector(selector);
      if (elem === null) {
        continue;
      }

      if (elem.matches('input[type="checkbox"]')) {
        elem.checked = !!object[key];
      } else if (elem.matches('select[multiple]')) {
        const options = Array.from(elem.querySelectorAll('option'));
        let [selected, checksum] = object[key].split('  ');

        if (checksum) {
          const crc = utils.crc32(JSON.stringify(options.map(x => x.value)), '62');
          if (crc !== checksum) {
            for (const elem of options) {
              elem.selected = elem.defaultSelected;
            }
            continue;
          }
        }

        for (const elem of options) {
          elem.selected = false;
        }
        for (const index of selected.split(' ')) {
          let [start, end] = index.split('-').map(x => utils.fromBaseN(x, 62));
          if (!(end >= start)) { end = start; }
          for (let i = start; i <= end; i++) {
            if (!options[i]) { break; }
            options[i].selected = true;
          }
        }
      } else {
        elem.value = object[key];
      }
    }
  }

  loadStorage(object = this.options, {
    prefix = this.STORAGE_PREFIX,
    schema = this.STORAGE_SCHEMA,
    schemaHandler = this.SCHEMA_HANDLER,
    forLocal = true,
    forSession = true,
  } = {}) {
    if (schemaHandler !== this.SCHEMA_HANDLER) {
      schemaHandler = Object.assign({}, this.SCHEMA_HANDLER, schemaHandler);
    }

    try {
      for (const [key, {name, type}] of Object.entries(schema)) {
        const storageKey = prefix + (name || key);
        const handler = (schemaHandler[type] || this.SCHEMA_HANDLER['string']).fromString;

        if (forSession) {
          var value = sessionStorage.getItem(storageKey);
          if (value !== null) {
            object[key] = handler(value);
            continue;
          }
        }

        if (forLocal) {
          var value = localStorage.getItem(storageKey);
          if (value !== null) {
            object[key] = handler(value);
            continue;
          }
        }
      }
    } catch (ex) {
      console.error(ex);
    }
    return object;
  }

  saveStorage(object = this.options, {
    prefix = this.STORAGE_PREFIX,
    schema = this.STORAGE_SCHEMA,
    schemaHandler = this.SCHEMA_HANDLER,
    forLocal = true,
    forSession = true,
  } = {}) {
    if (schemaHandler !== this.SCHEMA_HANDLER) {
      schemaHandler = Object.assign({}, this.SCHEMA_HANDLER, schemaHandler);
    }

    try {
      for (const [key, {name, type}] of Object.entries(schema)) {
        if (typeof object[key] === 'undefined') { continue; }
        const storageKey = prefix + (name || key);
        const handler = (schemaHandler[type] || this.SCHEMA_HANDLER['string']).toString;
        const value = handler(object[key]);
        if (forLocal) {
          localStorage.setItem(storageKey, value);
        }
        if (forSession) {
          sessionStorage.setItem(storageKey, value);
        }
      }
    } catch (ex) {
      console.error(ex);
    }
  }

  loadUrlParams(object = this.options, {
    url = location,
    forHash = true,
    schema = this.URL_PARAMS_SCHEMA,
    schemaHandler = this.SCHEMA_HANDLER,
  } = {}) {
    if (typeof url === 'string') {
      url = new URL(url);
    }
    if (schemaHandler !== this.SCHEMA_HANDLER) {
      schemaHandler = Object.assign({}, this.SCHEMA_HANDLER, schemaHandler);
    }

    let query;
    if (forHash) {
      query = url.hash.slice(1);
      if (!query.startsWith('?')) { query = ''; }
    } else {
      query = url.search;
    }
    const params = new URLSearchParams(query);

    for (let [key, {name, type}] of Object.entries(schema)) {
      name = name || key;
      if (!params.has(name)) { continue; }
      if (type && type.endsWith('[]')) {
        type = type.slice(0, -2);
        const handler = (schemaHandler[type] || this.SCHEMA_HANDLER['string']).fromString;
        object[key] = params.getAll(name).map(handler);
      } else {
        const handler = (schemaHandler[type] || this.SCHEMA_HANDLER['string']).fromString;
        object[key] = handler(params.get(name));
      }
    }
    return object;
  }

  setUrlParams(object = this.options, {
    url = location.href,
    forHash = true,
    schema = this.URL_PARAMS_SCHEMA,
    schemaHandler = this.SCHEMA_HANDLER,
  } = {}) {
    if (typeof url === 'string') {
      url = new URL(url);
    }
    if (schemaHandler !== this.SCHEMA_HANDLER) {
      schemaHandler = Object.assign({}, this.SCHEMA_HANDLER, schemaHandler);
    }

    let query;
    if (forHash) {
      query = url.hash.slice(1);
      if (!query.startsWith('?')) { query = ''; }
    } else {
      query = url.search;
    }
    const params = new URLSearchParams(query);

    for (let [key, {name, type}] of Object.entries(schema)) {
      if (typeof object[key] === 'undefined') { continue; }
      name = name || key;
      if (type && type.endsWith('[]')) {
        type = type.slice(0, -2);
        const handler = (schemaHandler[type] || this.SCHEMA_HANDLER['string']).toString;
        params.delete(name);
        for (let value of object[key]) {
          value = handler(value);
          params.append(name, value);
        }
      } else {
        const handler = (schemaHandler[type] || this.SCHEMA_HANDLER['string']).toString;
        const value = handler(object[key]);
        params.set(name, value);
      }
    }

    if (forHash) {
      url.hash = '?' + params.toString();
    } else {
      url.search = params.toString();
    }

    return url.href;
  }

  updateRuntimeOptions(options = this.options) {
    let useAncient = false;
    if (this.mainElem.getAttribute('data-render') === 'book') {
      let mode = options['useAncient'];

      switch (mode) {
        case "true":
          useAncient = true;
          break;
        case "false":
          useAncient = false;
          break;
        default: {
          // 1. 元資料有定義者（"古版", "今版"），取之
          // 2. 未定義者，預設為今版
          const meta = (this.loadBookMeta(this.mainElem)['版式'] || [undefined])[0];
          if (meta === '古版') {
            useAncient = true;
          } else if (meta === '今版') {
            useAncient = false;
          } else {
            useAncient = false;
          }
          break;
        }
      }
    }

    let useZhu = "diff";
    if (this.mainElem.getAttribute('data-render') === 'book') {
      let mode = options['useZhu'];

      switch (mode) {
        case "diff":
        case "accept":
        case "reject":
          useZhu = mode;
          break;
        default:
          // @TODO: 根據情況自動判斷
          useZhu = "diff";
          break;
      }
    }

    let useShu = "diff";
    if (this.mainElem.getAttribute('data-render') === 'book') {
      let mode = options['useShu'];

      switch (mode) {
        case "diff":
        case "accept":
        case "reject":
          useShu = mode;
          break;
        default:
          // @TODO: 根據情況自動判斷
          useShu = "diff";
          break;
      }
    }

    let useJiao = "diff";
    if (this.mainElem.getAttribute('data-render') === 'book') {
      let mode = options['useJiao'];

      switch (mode) {
        case "diff":
        case "accept":
        case "reject":
          useJiao = mode;
          break;
        default:
          // @TODO: 根據情況自動判斷
          useJiao = useAncient ? "reject" : "diff";
          break;
      }
    }

    let useDing = "diff";
    if (this.mainElem.getAttribute('data-render') === 'book') {
      let mode = options['useDing'];

      switch (mode) {
        case "diff":
        case "accept":
        case "reject":
          useDing = mode;
          break;
        default:
          // @TODO: 根據情況自動判斷
          useDing = useAncient ? "reject" : "diff";
          break;
      }
    }

    let useVertical = false;
    {
      let mode = options['useVertical'];

      switch (mode) {
        case "true":
          useVertical = true;
          break;
        case "false":
          useVertical = false;
          break;
        default: {
          // 1. 元資料有定義者（"橫書", "直書"），取之
          // 2. 未定義者，今版預設橫書，古版預設直書
          const meta = (this.loadBookMeta(this.mainElem)['直書'] || [undefined])[0];
          if (meta === '直書') {
            useVertical = true;
          } else if (meta === '橫書') {
            useVertical = false;
          } else {
            useVertical = !!useAncient;
          }
          break;
        }
      }
    }

    let fontFamily = false;
    {
      let mode = options['useFont'];

      switch (mode) {
        case "default":
          if (jchanzi.conf) {
            jchanzi.conf.fontName = '';
          }
          fontFamily = false;
          break;
        case "custom":
          if (jchanzi.conf) {
            jchanzi.conf.fontName = '';
          }
          fontFamily = utils.tidyCssPropertyValue('font-family', options['useFontCustom']) || 'unset';
          break;
        default:
          const font = utils.PAGE_INFO.hanzi_fonts[mode];
          if (font) {
            if (jchanzi.conf) {
              jchanzi.conf.fontName = font.type || '';
            }
            fontFamily = mode;
            break;
          }

          // @TODO: 根據情況自動判斷
          if (jchanzi.conf) {
            jchanzi.conf.fontName = '';
          }
          if (useAncient) {
            // 古版時使用中文字體，因英文字體的標點符號有時不能轉90度排
            fontFamily = `"DroidSansFallback", "WenQuanYi Zen Hei", "Heiti TC", "PMingLiU", "PMingLiU-ExtB", sans-serif`;
          } else {
            fontFamily = false;
          }
          break;
      }
    }

    let fontSize = options['fontSize'];

    let useHanzi = true;
    {
      let mode = options['useHanzi'];

      switch (mode) {
        case "basic":
        case "advanced":
          useHanzi = mode;
          break;
        case "false":
          useHanzi = false;
          break;
        default:
          // @TODO: 根據情況自動判斷
          useHanzi = "advanced";
          break;
      }
    }

    let prettify = false;
    {
      let mode = options['prettify'];

      switch (mode) {
        case "true":
          prettify = true;
          break;
        case "false":
          prettify = false;
          break;
        default:
          // @TODO: 根據情況自動判斷
          prettify = false;
          break;
      }
    }

    Object.assign(this.runtimeOptions, options, {
      useAncient,
      useZhu,
      useShu,
      useJiao,
      useDing,
      useVertical,
      fontFamily,
      fontSize,
      useHanzi,
      prettify,
    });
  }
}

return Object.assign(utils, {
  Jicheng,
});

}));
